package dslab.util;

import dslab.enums.Outcome;
import dslab.nameserver.INameserverRemote;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.rmi.RemoteException;
import java.util.Enumeration;
import java.util.concurrent.Callable;

/**
 * DMTPClient that is used for outgoing TransferServer transfers
 * (usually in the form of a task for a SendQueueHandler)
 */
public class DMTPClient implements Callable<Outcome> {
    // 15 second timeout
    private static final int TIMEOUT = 15*1000;

    private final String componentId;
    private final String domain;
    private final Message message;
    private final Pair<InetAddress, Integer> monitoring;
    private final INameserverRemote root;
    private final boolean notifySenderOnError;
    private final boolean logging;

    // these members become relevant in a static context where no lookup is performed
    private final boolean resolve;
    private final String static_host;
    private final int static_port;

    /**
     * creates a new DMTPClient
     *
     * @param componentId string representation of component
     * @param domain host domain
     * @param message message to transmit
     * @param monitoring monitoring information as <code>Pair<InetAddress, Integer></code>
     * @param root remote object for the root nameserver
     * @param notifySenderOnError whether to loop-back transmission errors to the sender
     */
    public DMTPClient(String componentId, String domain, Message message, Pair<InetAddress, Integer> monitoring,
                      INameserverRemote root, boolean notifySenderOnError) {
        this.componentId = componentId;
        this.domain = domain;
        this.message = message;
        this.monitoring = monitoring;
        this.root = root;
        this.notifySenderOnError = notifySenderOnError;
        this.resolve = true;
        this.logging = true;

        // when resolving dynamically, these are not needed
        this.static_host = null;
        this.static_port = 0;
    }

    /**
     * special constructor for a DMTP client that performs no dynamic lookup and is instead loaded with a static
     * configuration
     *
     * @param componentId string representation of component
     * @param message message to transmit
     * @param host host to contact
     * @param port port to contact at
     * @param logging whether to output logging information
     */
    public DMTPClient(String componentId, Message message, String host, int port, boolean logging) {
        this.componentId = componentId;
        this.message = message;
        this.static_host = host;
        this.static_port = port;
        this.logging = logging;
        this.notifySenderOnError = false;
        this.resolve = false;

        // when resolving statically, these are not needed
        this.monitoring = null;
        this.domain = null;
        this.root = null;
    }

    @Override
    public Outcome call() {
        // notify user of what is happening
        System.out.printf(
            "@%s: processing message %s%n",
            componentId,
            message.toString()
        );

        // by default, outcome is a success
        Outcome outcome = Outcome.SUCCESS;

        // this section is concerned with the querying of host information
        String host;
        int port;
        // in the case that a dynamic lookup should be performed
        if (resolve) {
            // make lookup
            Pair<String, Integer> target = dynamicDomainLookup();
            host = target.key();
            port = target.value();
            // if there was a failure during a lookup
            if (host == null && port == 0)
                return Outcome.UNKNOWN_HOST;
        // no dynamic lookup
        } else {
            // get static configuration
            host = this.static_host;
            port = this.static_port;
        }

        // send monitoring statistics (if monitoring is enabled, which is not the case for message clients)
        sendMonitoringStatistics(host, port);

        // construct arrays describing DMTP exchange
        // from the perspective of a client
        String[] requests =  {
                "ok DMTP",
                "ok",
                //String.format("ok %d", groupedRecipients.get(domain).size()),
                "ok",
                "ok",
                "ok",
                "ok",
                "ok",
                "ok bye"
        };
        String[] responses = {
                "begin",
                String.format("to %s", message.recipients),
                String.format("from %s", message.sender),
                String.format("subject %s", message.subject),
                String.format("data %s", message.data),
                "send",
                "quit"
        };
        String localAddress = null;
        // try-with-resources block for resource objects
        try (
            Socket client = new Socket(host, port);
            PrintWriter clientOut = new PrintWriter(client.getOutputStream(), true);
            BufferedReader clientIn = new BufferedReader(new InputStreamReader(client.getInputStream()))
        ) {
            localAddress = client.getLocalAddress().toString();
            // register timeout
            client.setSoTimeout(TIMEOUT);
            // follow specified protocol exchange
            for (int rounds = 0; rounds < responses.length; rounds++) {
                String line = clientIn.readLine();
                if (!line.contains(requests[rounds]))
                    throw new DMTPProtocolException(line);
                clientOut.println(responses[rounds]);
            }

            // notify user of successful transfer
            if (logging)
                System.out.printf("@%s: message delivered successfully%n", componentId);
        } catch (DMTPProtocolException | IOException exception) {
            // deal with various exception cases
            if (exception instanceof DMTPProtocolException) {
                if (logging)
                    System.err.printf("@%s: an error has occurred: server has responded unexpectedly %s:%d%n", componentId, host, port);
                outcome = Outcome.PROTOCOL_ERROR;
            } else if (exception instanceof SocketTimeoutException) {
                if (logging)
                    System.err.printf("@%s: an error has occurred: socket timed out %s:%d%n", componentId, host, port);
                outcome = Outcome.SOCKET_TIMEOUT;
            } else if (exception instanceof UnknownHostException) {
                if (logging)
                    System.err.printf("@%s: an error has occurred: cannot reach the host %s:%d%n", componentId, host, port);
                outcome = Outcome.UNKNOWN_HOST;
            } else {
                // anything that falls under this category is an IOException
                if (logging) {
                    System.err.printf("@%s: an error has occurred: i/o error for host %s:%d%n", componentId, host, port);
                    exception.printStackTrace();
                }
                outcome = Outcome.IO_ERROR;
            }

            // in case of an error, send error notification
            sendNotification(localAddress);
        }

        return outcome;
    }

    private Pair<String, Integer> dynamicDomainLookup() {
        String hostWithPort, host;
        int port;
        try {
            System.out.printf("trying to look up domain %s%n", domain);
            hostWithPort = root.lookupDomain(domain);
            if (hostWithPort == null) {
                sendNotification(null);
                System.err.printf("@%s: a fatal error has occurred: the domain %s was not able to be located", componentId, domain);
                return new Pair<>(null, null);
            }

            // query relevant information and return
            host = hostWithPort.split(":")[0];
            port = Integer.parseInt(hostWithPort.split(":")[1]);
            return new Pair<>(host, port);
        } catch (RemoteException exception) {
            System.err.printf("@%s: a fatal error has occurred: the domain %s has produced an RMI-Error", componentId, domain);
            exception.printStackTrace();
            sendNotification(null);
            return new Pair<>(null, null);
        } catch (NumberFormatException exception) {
            System.err.printf("@%s: a fatal error has occurred: the domain %s has an illegal port number%n", componentId, domain);
            exception.printStackTrace();
            sendNotification(null);
            return new Pair<>(null, null);
        }
    }

    private void sendMonitoringStatistics(String host, int port) {
        // opt out of monitoring if no monitoring server was supplied
        if (monitoring == null)
            return;

        // send a UDP packet into the void in hopes of reaching a monitoring server
        try (DatagramSocket client = new DatagramSocket()) {
            // bookkeeping
            String payload = String.format("%s:%d %s%n", host, port, message.sender);
            byte[] buffer = payload.getBytes(StandardCharsets.UTF_8);
            DatagramPacket packet = new DatagramPacket(buffer, buffer.length, monitoring.key(), monitoring.value());
            client.send(packet);
            System.out.printf(
                    "@%s: monitoring \"%s:%d %s\" at %s:%d%n",
                    componentId,
                    host,
                    port,
                    message.sender,
                    monitoring.key(),
                    monitoring.value()
            );
        } catch (SocketException exception) {
            System.err.printf(
                    "@%s: unable to open UDP socket for sending: %s:%d%n",
                    componentId,
                    monitoring.key(),
                    monitoring.value()
            );
        } catch (IOException exception) {
            System.err.printf(
                    "@%s: an error has occurred: i/o error for host %s:%d%n",
                    componentId,
                    monitoring.key(),
                    monitoring.value()
            );
            exception.printStackTrace();
        }
    }

    private void sendNotification(String localAddress) {
        // opt out of failure retransmission without appropriate flag
        if (!notifySenderOnError)
            return;

        Message failureMessage = new Message();
        String address = localAddress == null ?
                DMTPClient.getFirstNonLoopbackAddress(true, false) :
                localAddress;

        // reformat message into failure message
        failureMessage.sender = String.format("mailer@%s", address);
        failureMessage.recipients = message.sender;
        failureMessage.subject = String.format("failed delivery: %s", message.subject);
        failureMessage.data = String.format(
                "for at least one recipient the following message has not been delivered: %s",
                message.data
        );

        // various transformations to find new host for failure message transmission
        String newDomain = message.sender.split("@")[1];

        // attempt failure message transmission
        DMTPClient dmtpClient = new DMTPClient(
            String.format("%s-failure-sender", componentId),
            newDomain,
            failureMessage,
            monitoring,
            // prevent infinite recursion
            root,
            false
        );

        // run the new failure message sender
        dmtpClient.call();
    }

    /**
     * auxiliary method to go through interfaces and find first (non-loopback) address
     * snippet sourced from:
     * https://stackoverflow.com/questions/901755/how-to-get-the-ip-of-the-computer-on-linux-through-java/901943#901943
     * (slightly modified from source)
     * @param preferIpv4 whether ipv4 is preferred
     * @param preferIPv6 whether ipv6 is preferred
     * @return first non-loopback ipv4, ipv6 address (or first address in general if both are preferred)
     */
    private static String getFirstNonLoopbackAddress(boolean preferIpv4, boolean preferIPv6) {
        Enumeration<NetworkInterface> en;
        try {
            en = NetworkInterface.getNetworkInterfaces();
        } catch (SocketException exception) {
            return "127.0.0.1";
        }

        while (en.hasMoreElements()) {
            NetworkInterface i = (NetworkInterface) en.nextElement();
            for (Enumeration<InetAddress> en2 = i.getInetAddresses(); en2.hasMoreElements();) {
                InetAddress address = en2.nextElement();
                if (!address.isLoopbackAddress()) {
                    if (address instanceof Inet4Address) {
                        if (preferIPv6) {
                            continue;
                        }
                        return address.getHostAddress();
                    }
                    if (address instanceof Inet6Address) {
                        if (preferIpv4) {
                            continue;
                        }
                        return address.getHostAddress();
                    }
                }
            }
        }

        return "127.0.0.1";
    }
}
